🎖️GitЯра🎖️
Commit 8899fdb2cf2ae1bc17f3bdce6bf5e93f5222f8a4
Parents : 7ab7d81
Author : simulationstation <32910678+simulationstation@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-11T05:17:17-10:00
Committer : GitHub <noreply@github.com>
Date : 2026-08-11T15:17:17Z
fix(map): reuse cluster items across camera recompositions (#6605)
Changes
2 files changed, 209 insertions(+), 15 deletions(-)
Diff
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index 6f1d9bf7ed..2669adac83 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -107,6 +107,9 @@ import com.google.maps.android.data.renderer.model.PolygonStyle
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
@@ -146,11 +149,13 @@ import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.longitude
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_tile_source
+import org.meshtastic.core.resources.now
import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.resources.track_point
+import org.meshtastic.core.resources.unknown
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.Map
@@ -402,6 +407,13 @@ fun MapView(
}
val myNodeNum = mapViewModel.myNodeNum
+ val relativeTimeBucket = rememberRelativeTimeBucket()
+ val nodeClusterItems =
+ rememberNodeClusterItems(
+ nodes = if (mode is GoogleMapMode.Main) filteredNodes else emptyList(),
+ myNodeNum = myNodeNum,
+ relativeTimeBucket = relativeTimeBucket,
+ )
val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle()
val theme by mapViewModel.theme.collectAsStateWithLifecycle()
val dark =
@@ -669,21 +681,7 @@ fun MapView(
when (mode) {
is GoogleMapMode.Main ->
MainMapContent(
- nodeClusterItems =
- filteredNodes.map { node ->
- val latLng =
- LatLng(
- (node.position.latitude_i ?: 0) * DEG_D,
- (node.position.longitude_i ?: 0) * DEG_D,
- )
- NodeClusterItem(
- node = node,
- nodePosition = latLng,
- nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}",
- nodeSnippet = "${node.user.long_name}",
- myNodeNum = myNodeNum,
- )
- },
+ nodeClusterItems = nodeClusterItems,
mapFilterState = mapFilterState,
navigateToNodeDetails = navigateToNodeDetails,
displayableWaypoints = displayableWaypoints,
@@ -1025,6 +1023,53 @@ fun MapView(
}
}
+private const val SECONDS_PER_MINUTE = 60L
+private const val MILLIS_PER_SECOND = 1_000L
+
+@Composable
+private fun rememberRelativeTimeBucket(): Long {
+ val buckets = remember { relativeTimeBuckets() }
+ return buckets.collectAsStateWithLifecycle(initialValue = nowSeconds / SECONDS_PER_MINUTE).value
+}
+
+internal fun relativeTimeBuckets(now: () -> Long = { nowSeconds }): Flow<Long> = flow {
+ while (true) {
+ val currentSeconds = now()
+ emit(currentSeconds / SECONDS_PER_MINUTE)
+ val secondsUntilNextMinute = SECONDS_PER_MINUTE - currentSeconds.mod(SECONDS_PER_MINUTE)
+ delay(secondsUntilNextMinute * MILLIS_PER_SECOND)
+ }
+}
+
+/**
+ * Materializes the native clustering model used by the Google map.
+ *
+ * Camera state invalidates [MapView] on every movement frame, and its filters produce a new-but-equal [List] each time.
+ * Using that structural value as a key avoids rebuilding every [NodeClusterItem] (and its strings/[LatLng]) for
+ * camera-only changes. [relativeTimeBucket] deliberately refreshes the relative marker titles once per minute.
+ */
+@Composable
+internal fun rememberNodeClusterItems(
+ nodes: List<Node>,
+ myNodeNum: Int?,
+ relativeTimeBucket: Long,
+): List<NodeClusterItem> {
+ val unknownText = stringResource(Res.string.unknown)
+ val nowText = stringResource(Res.string.now)
+ return remember(nodes, myNodeNum, relativeTimeBucket, unknownText, nowText) {
+ nodes.map { node ->
+ val latLng = LatLng((node.position.latitude_i ?: 0) * DEG_D, (node.position.longitude_i ?: 0) * DEG_D)
+ NodeClusterItem(
+ node = node,
+ nodePosition = latLng,
+ nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time, unknownText, nowText)}",
+ nodeSnippet = node.user.long_name,
+ myNodeNum = myNodeNum,
+ )
+ }
+ }
+}
+
// region --- Main Map Content ---
@Suppress("LongParameterList")
diff --git a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapNodeClusterItemsTest.kt b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapNodeClusterItemsTest.kt
new file mode 100644
index 0000000000..fac8e6093e
--- /dev/null
+++ b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapNodeClusterItemsTest.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map
+
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runComposeUiTest
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.app.map.model.NodeClusterItem
+import org.meshtastic.core.model.Node
+import org.meshtastic.proto.Position
+import org.meshtastic.proto.User
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+import kotlin.test.assertNotSame
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalTestApi::class, ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class MapNodeClusterItemsTest {
+
+ @Test
+ fun `camera-only recompositions reuse two thousand cluster items`() = runComposeUiTest {
+ val nodes = testNodes(count = NODE_COUNT)
+ var cameraFrame by mutableIntStateOf(0)
+ val observedLists = mutableListOf<List<NodeClusterItem>>()
+
+ setContent {
+ val filteredNodes = nodes.filter { cameraFrame >= 0 }
+ val items =
+ rememberNodeClusterItems(
+ nodes = filteredNodes,
+ myNodeNum = null,
+ relativeTimeBucket = FIXED_TIME_BUCKET,
+ )
+ SideEffect { observedLists += items }
+ }
+ waitForIdle()
+
+ repeat(SIMULATED_CAMERA_FRAMES) {
+ runOnIdle { cameraFrame += 1 }
+ waitForIdle()
+ }
+
+ assertEquals(SIMULATED_CAMERA_FRAMES + 1, observedLists.size)
+ val first = observedLists.first()
+ assertEquals(NODE_COUNT, first.size)
+ observedLists.drop(1).forEach { items ->
+ assertTrue(first === items, "camera-only changes must retain the same 2,000-item list")
+ }
+ }
+
+ @Test
+ fun `node changes and minute rollover refresh cluster items`() = runComposeUiTest {
+ var nodes by mutableStateOf(testNodes(count = 1))
+ var relativeTimeBucket by mutableLongStateOf(FIXED_TIME_BUCKET)
+ var latestItems: List<NodeClusterItem> = emptyList()
+
+ setContent {
+ val items =
+ rememberNodeClusterItems(nodes = nodes, myNodeNum = null, relativeTimeBucket = relativeTimeBucket)
+ SideEffect { latestItems = items }
+ }
+ waitForIdle()
+ val initialItems = latestItems
+
+ runOnIdle {
+ val node = nodes.single()
+ nodes = listOf(node.copy(user = node.user.copy(short_name = "NEW")))
+ }
+ waitForIdle()
+ val changedNodeItems = latestItems
+ assertNotSame(initialItems, changedNodeItems)
+ assertTrue(changedNodeItems.single().title.startsWith("NEW "))
+
+ runOnIdle { relativeTimeBucket += 1 }
+ waitForIdle()
+ assertNotSame(changedNodeItems, latestItems)
+ }
+
+ @Test
+ fun `relative time bucket advances at the next minute boundary`() = runTest {
+ val startSeconds = FIXED_TIME_BUCKET * 60 + 30
+ val observedBuckets = mutableListOf<Long>()
+ backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ relativeTimeBuckets { startSeconds + testScheduler.currentTime / 1_000 }.take(2).toList(observedBuckets)
+ }
+
+ runCurrent()
+ assertEquals(listOf(FIXED_TIME_BUCKET), observedBuckets)
+
+ advanceTimeBy(29_999)
+ runCurrent()
+ assertEquals(listOf(FIXED_TIME_BUCKET), observedBuckets)
+
+ advanceTimeBy(1)
+ runCurrent()
+ assertEquals(listOf(FIXED_TIME_BUCKET, FIXED_TIME_BUCKET + 1), observedBuckets)
+ }
+
+ private fun testNodes(count: Int): List<Node> = List(count) { index ->
+ Node(
+ num = index + 1,
+ user = User(id = "!${index + 1}", long_name = "Node ${index + 1}", short_name = "N$index"),
+ position =
+ Position(
+ latitude_i = 210_000_000 + index,
+ longitude_i = -1_570_000_000 + index,
+ time = 1_700_000_000,
+ ),
+ )
+ }
+
+ private companion object {
+ const val NODE_COUNT = 2_000
+ const val SIMULATED_CAMERA_FRAMES = 30
+ const val FIXED_TIME_BUCKET = 123L
+ }
+}
Served by rngit 1.4.2 - Generated in 0.06s